package org.jverify;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Deque;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import org.apache.commons.io.FileUtils;
import org.jverify.data.pojo.FileData;
import org.jverify.data.provider.DataProvider;
import org.jverify.exceptions.FileNotReplacedException;
import org.jverify.utility.CompressedStreamHandler;
import org.jverify.utility.FileDigest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Class responsible for the main bulk of the functionality. Stores, replaces
 * and verifies files as well as providing <br>
 * notification of the actions taken in the form of an email message.
 * 
 * @author Yiannis Mavroukakis
 */

public class FileVerify {

	private final RunMode runMode;
	private final DataProvider dataProvider;
	private static final Logger LOG = LoggerFactory.getLogger(FileVerify.class);
	private static final int THREAD_WORKERS = Runtime.getRuntime().availableProcessors();
	private CountDownLatch workDone;
	private long fileCount;

	/**
	 * @param runMode
	 * @param settings
	 * @param dataProvider
	 */

	public FileVerify(final RunMode runMode, final DataProvider dataProvider) {
		this.runMode = runMode;
		this.dataProvider = dataProvider;
		if (runMode == null) {
			throw new IllegalArgumentException();
		}

		if (runMode == RunMode.INITIALISE || runMode == RunMode.INITIALISE_STORE_CONTENT) {
			dataProvider.initialise();
		}
		dataProvider.start();
	}

	public void initialise(final List<File> files) throws IOException, InterruptedException {
		final Deque<File> filesCopy = new LinkedList<File>(files);
		final ExecutorService executor = Executors.newFixedThreadPool(THREAD_WORKERS);
		workDone = new CountDownLatch(files.size());
		fileCount = files.size();
		while (!filesCopy.isEmpty()) {
			executor.execute(consume(filesCopy.poll()));
		}
		try {
			workDone.await();
		}
		finally {
			executor.shutdown();
		}
	}

	/**
	 * Stores a file's hash into the database, optionaly storing its contents as
	 * well, based on RunMode
	 * 
	 * @param fileSum
	 *            A <code>byte</code> array containing the hash
	 * @param fileName
	 *            A <code>String</code> containing the full filepath and
	 *            filename
	 */
	public void store(final FileData fileData) {
		if (fileData != null) {
			dataProvider.store(fileData);
		}
	}

	/**
	 * Removes a FileData object from the database
	 * 
	 * @param fileData
	 *            the FileData object to remove
	 */
	public void remove(final FileData fileData) {
		if (fileData != null) {
			dataProvider.remove(fileData);
		}
	}

	/**
	 * Replaces a FileData object in the database with a new one
	 * 
	 * @param oldFileData
	 *            the old FileData to remove
	 * @param newFileData
	 *            the new FileData to store
	 */
	public void replace(final FileData oldFileData, final FileData newFileData) {
		remove(oldFileData);
		store(newFileData);
	}

	/**
	 * Retrieves all files in a given directory. Does not recurse into other
	 * directories.
	 * 
	 * @param directory
	 *            <code>String</code> containing the full directory path
	 * @return A <code>List</code> containing the full pathname + filename(s).
	 */
	public List<File> getFiles(final String[] directories) {

		final List<File> fileNames = new ArrayList<File>();
		for (final String directory : directories) {
			@SuppressWarnings("unchecked")
			final Iterator<File> directoryIterator = FileUtils.iterateFiles(new File(directory), null, false);
			while (directoryIterator.hasNext()) {
				fileNames.add(directoryIterator.next());
			}
		}
		/**
		 * final List<String> fileNames = new ArrayList<String>(); for (final
		 * String directory : directories) { final File dir = new
		 * File(directory); for (final File file : dir.listFiles()) { if
		 * (file.isFile()) { try { fileNames.add(file.getCanonicalPath()); }
		 * catch (final IOException e) {
		 * LOG.warn("Unable to access full path to file {}", file.getName()); }
		 * } } }
		 **/
		return fileNames;
	}

	/**
	 * Performs file verification. Retrieves the list of<br>
	 * files kept in the database and iterates through their MD5's while
	 * checking<br>
	 * the corresponding file on the filesystem. Depending on the configuration<br>
	 * it will either email a warning message to a designated address, or it
	 * will<br>
	 * email a warning and try to replace the corrupt file.
	 */
	public void verify(final Alerter alerter) {

		final List<String> altered = new ArrayList<String>();
		final List<FileData> files = dataProvider.getAll();
		int total = files.size();

		for (final FileData fileData : files) {
			try {
				final File file = new File(fileData.getFileName());
				if (file.exists() && file.isFile()
						&& !MessageDigest.isEqual(FileDigest.calculateSum(file), fileData.getSignature())) {
					replace(file);
					altered.add(fileData.getFileName());
				}
			}
			catch (final IOException e) {
				LOG.warn("I/O error: {}", e.getLocalizedMessage());
			}
			catch (final FileNotReplacedException e) {
				LOG.warn("File {} not replaced: error {}", fileData.getFileName(), e.getLocalizedMessage());
			}
			finally {
				LOG.warn("{} remaining", --total);
			}
		}

		if (!altered.isEmpty()) {
			alerter.sendAlert(altered);
			return;
		}
		LOG.info("No altered files found");
	}

	/**
	 * Performs a "refresh" of the files stored in the database, after an
	 * upgrade of the files in the filesystem.
	 * 
	 * @param fileNames
	 *            <code>List[]</code> of the files to be refreshed.
	 * @throws IOException
	 */
	public void refresh(final List<File> fileNames) throws IOException {

		for (final File fileName : fileNames) {
			final FileData oldFileData = dataProvider.get(fileName.getAbsolutePath());
			if (oldFileData != null) {
				final FileData newFileData = new FileData(FileDigest.calculateSum(fileName), fileName.getAbsolutePath());
				if (oldFileData.getBinaryContent() != null) {
					oldFileData.setBinaryContent(FileDigest.getBytes(fileName));
				}
				replace(oldFileData, newFileData);
			}
		}
	}

	/**
	 * Replaces an infected filename, with the binary data held in the database.<br>
	 * This method will try to access the file and lock it, while it is being
	 * replaced.<br>
	 * If it cannot do so, it will throw a {@link FileNotReplacedException}.
	 * 
	 * @param file
	 *            the file to replace
	 * @throws FileNotReplacedException
	 *             If the file could not be replaced.
	 */
	private void replace(final File file) throws FileNotReplacedException {

		if (runMode == RunMode.REPLACE) {
			FileLock fLock = null;
			FileChannel fChan = null;
			try {
				final FileData fData = dataProvider.get(file.getCanonicalPath());
				final byte[] binaryData = fData.getBinaryContent();
				if (binaryData == null) {
					return;
				}
				// TODO change this to nio classes
				fChan = new FileOutputStream(file).getChannel();
				fLock = fChan.tryLock();
				if (fLock != null) {
					final ByteBuffer byteBuffer = ByteBuffer.wrap(CompressedStreamHandler.deCompressData(binaryData));
					fChan.write(byteBuffer);
				}
			}
			catch (final IOException e) {
				throw new FileNotReplacedException(e);
			}
			finally {
				if (fChan != null) {
					try {
						if (fLock != null) {
							fLock.release();
							fChan.close();
						}
					}
					catch (final IOException e) {
						LOG.warn("File channel or lock was not succesfully released, error:{}", e.getLocalizedMessage());
					}
				}
			}
		}
	}

	/**
	 * @param file
	 * @throws IOException
	 */
	private void processFile(final File file) throws IOException {
		final FileData fileData = new FileData(FileDigest.calculateSum(file), file.getAbsolutePath());
		if (runMode == RunMode.INITIALISE_STORE_CONTENT) {
			fileData.setBinaryContent(FileDigest.getBytes(file));
		}
		store(fileData);
	}

	private Runnable consume(final File file) { // NOPMD by imavroukakis on
												// 10/18/10 10:28 AM
		return new Runnable() { // NOPMD by imavroukakis on 10/18/10 10:28 AM
			@Override
			public void run() {
				if (file != null) {
					try {
						processFile(file);
						synchronized (this) {
							fileCount--;
							LOG.info("{} file(s) remaining", fileCount);
						}
					}
					catch (final IOException ioe) {
						LOG.error("{} cound not be processed, error {}", file, ioe.getLocalizedMessage());
					}
				}
				workDone.countDown();
			}
		};
	}
}